/**
 * The MIT License
 *
 * Copyright (c) 2010 Benjamin Klum
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package org.helgoboss.caching_obr_ref_impl

import org.osgi.service.obr._
import org.apache.felix.bundlerepository.impl.{RepositoryAdminImpl, ResourceImpl}
import org.apache.felix.bundlerepository.impl.wrapper.Wrapper
import org.apache.felix.bundlerepository.impl.wrapper.ResourceWrapper
import org.apache.felix.utils.log.Logger 
import java.net.{URL, URLConnection}
import org.osgi.framework.{BundleActivator, BundleContext, ServiceRegistration, Constants}
import org.helgoboss.caching_obr.CachingRepositoryAdmin
import java.security.MessageDigest
import java.math.BigInteger
import java.io.{File, FileOutputStream, IOException}
import org.apache.felix.bundlerepository.impl.{Base64Encoder, FileUtil, DataModelHelperImpl}
import org.helgoboss.osgi_additions._
import org.helgoboss.document_database.UseDatabase
import org.helgoboss.scala_additions.RichFile
import org.helgoboss.scala_additions.Implicits._
import java.net.URL

object CachingRepositoryAdminRefImpl {
    private val ServiceRanking = 1
    private val RepositoriesBucketId = "org_helgoboss_cachingObrRefImpl_repositories"
}

class CachingRepositoryAdminRefImpl extends CachingRepositoryAdmin with OsgiContext with OsgiProvider with OsgiConsumer with OsgiLogging {
    import CachingRepositoryAdminRefImpl._
    
    
    
    
    private var originalAdmin: RepositoryAdmin = _
    private var adminImpl: RepositoryAdminImpl = _
    private val surfaceRepositoryByOriginalUrl = new collection.mutable.LinkedHashMap[URL, Repository]
    private val surfaceRepositoryByMd5Hash = new collection.mutable.HashMap[String, Repository]
    
    whenBundleActive {
        onStart {
            val logger = new Logger(bundleContext)
            adminImpl = new RepositoryAdminImpl(bundleContext, logger)
            originalAdmin = Wrapper.wrap(adminImpl)
            this.providesService[RepositoryAdmin, CachingRepositoryAdmin](Constants.SERVICE_RANKING -> ServiceRanking)
            restoreRepositories
        }
        onStop {
            saveRepositories
            surfaceRepositoryByOriginalUrl.clear
            surfaceRepositoryByMd5Hash.clear
            adminImpl.dispose
            adminImpl = null
            originalAdmin = null
        }
    }
    
    def useDatabase = requiredService[UseDatabase]
    
    def addRepository(repositoryUrl: URL) = {
        addRepository(repositoryUrl, updateProgress = None)
    }
    
    private lazy val repositoryCacheDir = RichFile.userHomeDir / ".caching-obr"
    
    private def getCachedRepoFile(repositoryUrl: URL) = {
        val cachedRepoFileName = md5Hash(repositoryUrl.toString)
        /* Don't only put the XML files in the repositoryCacheDir, but create for every repository a sub directory. 
         * This is essential for reenabling relative URI support later.
         */
        val cachedRepoDirName = cachedRepoFileName
        new File(repositoryCacheDir, cachedRepoDirName + "/" + cachedRepoFileName)
    }
    
    private def md5Hash(s: String) = {
        val digest = MessageDigest.getInstance("MD5")
        digest.update(s.getBytes, 0, s.length)
        new BigInteger(1, digest.digest).toString(16)
    }
    
    
    def discoverResources(filter: String): Array[Resource] = {
        val resources = originalAdmin.discoverResources(filter)
        resources.foreach(makeSureUrlRefersToOriginalRepoLocation)
        resources
    }
    
    /**
     * Transform URL in order to support relative URIs. The original Felix RepositoryAdmin already converted relative URIs to absolute URLs but
     * they have been resolved to the cached repository location, not to the original location. So we have to convert the URLs that refer
     * to the cached repository location to URLs that refer to the original location. I'm very sorry for the following 
     * ugly construct. But I didn't want to rewrite the felix bundle repository implementation.
     */
    private def makeSureUrlRefersToOriginalRepoLocation(obrResource: Resource) {
        if (refersToCachedRepo(obrResource.getURL.toString)) {
            /* We can assume the resource object was created by felix and therefore is an instance of ResourceWrapper */
            val obrResourceWrapper = obrResource.asInstanceOf[ResourceWrapper]
            /* We need to extract the wrapped felix-specific resource now in order to change it */
            val fields = obrResourceWrapper.getClass.getDeclaredFields
            val resourceField = fields(0)
            resourceField.setAccessible(true)
            val wrappedResource = resourceField.get(obrResourceWrapper).asInstanceOf[ResourceImpl]
            val uriString = wrappedResource.getURI
            /* URL is pointing to the cached repository location. Make it point to the original location. */
            /* Extract the relative part of the URL, which comes after the cached repository location */
            val newUrl = convertToUrlReferringToOriginalRepositoryLocation(uriString)
            wrappedResource.put("uri", newUrl.toString)
        }
    }
    
    private def refersToCachedRepo(uriString: String) = {
        val cachedReposBaseString = repositoryCacheDir.toURI.toURL.toString
        uriString.startsWith(cachedReposBaseString)
    }
    
    private def convertToUrlReferringToOriginalRepositoryLocation(uriString: String) = {
        val cachedReposBaseString = repositoryCacheDir.toURI.toURL.toString
        /* Remove first part pointing to the base repository cache dir */
        val relativeUrlPartWithMd5Hash = uriString.substring(cachedReposBaseString.size)
        /* The first path component of the result is named after the md5Hash. Extract it. */
        val indexOfFirstSlash = relativeUrlPartWithMd5Hash.indexOf("/")
        val md5Hash = relativeUrlPartWithMd5Hash.substring(0, indexOfFirstSlash)
        val relativeUrlPart = relativeUrlPartWithMd5Hash.substring(indexOfFirstSlash + 1)
        val originalRepoUrl = surfaceRepositoryByMd5Hash(md5Hash).getURL
        new URL(originalRepoUrl, relativeUrlPart)
    }
                    
    
    def getResource(resourceId: String) = {
        val resource = originalAdmin.getResource(resourceId)
        if (resource != null) {
            makeSureUrlRefersToOriginalRepoLocation(resource)
        }
        resource
    }
    
    def listRepositories = {
        surfaceRepositoryByOriginalUrl.values.toArray
    }
    
    def removeRepository(repositoryUrl: URL) = {
        surfaceRepositoryByOriginalUrl -= repositoryUrl
        surfaceRepositoryByMd5Hash -= md5Hash(repositoryUrl.toString)
        val cachedRepoFile = getCachedRepoFile(repositoryUrl)
        val conditionOne = originalAdmin.removeRepository(cachedRepoFile.toURI.toURL)
        val conditionTwo = if (cachedRepoFile.exists) {
            cachedRepoFile.delete
            true
        } else {
            false
        }
        saveRepositories
        conditionOne && conditionTwo
    }
    
    def resolver = {
        Wrapper.wrap(new LoggingResolver(delegate = adminImpl.resolver, logger = log))
    }
    
    def refreshRepository(repositoryUrl: URL) = {
        removeRepository(repositoryUrl)
        addRepository(repositoryUrl)
    }
    
    def addRepository(repositoryUrl: URL, updateProgress: Option[(Int) => Unit]) = {
        val cachedRepoFile = getCachedRepoFile(repositoryUrl)
        val cachedRepo = try {
            if (!cachedRepoFile.exists) {
                download(from = repositoryUrl, to = cachedRepoFile, updateProgress = updateProgress)
            }
            originalAdmin.addRepository(cachedRepoFile.toURI.toURL)
        } catch {
            case e: Exception =>
                /* Something went wrong with the download. Remove the cached repo file so we have the chance to download correctly later. */
                cachedRepoFile.delete
                throw e
        }
        val surfaceRepo = new Repository {
            def getLastModified = cachedRepo.getLastModified
            def getName = cachedRepo.getName
            def getResources = cachedRepo.getResources
            def getURL = repositoryUrl
            override def hashCode = getURL.hashCode
            override def equals(other: Any) = getURL == other.asInstanceOf[Repository].getURL
        }
        surfaceRepositoryByOriginalUrl += repositoryUrl -> surfaceRepo
        surfaceRepositoryByMd5Hash += md5Hash(repositoryUrl.toString) -> surfaceRepo
        saveRepositories
        surfaceRepo
    }

    private def download(from: URL, to: File, updateProgress: Option[(Int) => Unit]) {
        to.getParentFile.mkdirs
        val bufferSize = 1024
        val connection = from.openConnection
        FileUtil.setProxyAuth(connection)
        connection.connect
        val contentLength = connection.getContentLength
        val is = connection.getInputStream
        val os = new FileOutputStream(to)
        try {
            var eof = false
            var totalNumWrittenBytes = 0
            var previousProgress = -1
            while(!eof) {
                val buffer = new Array[Byte](bufferSize)
                val numReadBytes = is.read(buffer, 0, bufferSize)
                if (numReadBytes > 0) {
                    os.write(buffer, 0, numReadBytes)
                    totalNumWrittenBytes += numReadBytes
                    if (contentLength > 0) {
                        val progress = (100 * totalNumWrittenBytes) / contentLength
                        if (progress != previousProgress) {
                            updateProgress.foreach(_(progress))
                            previousProgress = progress
                        }
                    }
                } else {
                    eof = true
                }
                
            }
        } catch {
            case ex: Throwable =>
                to.delete
                throw ex
        } finally {
            os.close
            is.close
        }
    }
    
    
    private def saveRepositories {
        /* Save repositories to document database */
        useDatabase { db =>
            log.debug("Opened database. Get bucket, remove all previous content and fill it with documents containing current repository URLs.")
            val bucket = db.findOrAddBucket(RepositoriesBucketId)
            bucket.clear
            listRepositories.foreach { repo =>
                val doc = bucket.addDocument
                doc("url") = repo.getURL.toString
                doc.save
            }
        }
    }
    
    private def restoreRepositories {
        /* Read repositories from document database */
        useDatabase { db =>
            log.debug("Opened database. Load repositories if any.")
            db.findBucket(RepositoriesBucketId).foreach { bucket =>
                try {
                    bucket.documents.map { doc =>
                        new URL(doc.get[String]("url").get)
                    }.foreach(addRepository)
                } catch {
                    case ex: IOException => log.warn("Couldn't add a repository", ex)
                }
            }
        }
    }
}